package com.mikesamuel.dateseries.client;

import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;

import com.google.gwt.dom.client.ButtonElement;
import com.google.gwt.dom.client.DivElement;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.InputElement;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.dom.client.TableCellElement;
import com.google.gwt.dom.client.TableElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.TableSectionElement;
import com.google.gwt.event.dom.client.BlurEvent;
import com.google.gwt.event.dom.client.BlurHandler;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.FocusEvent;
import com.google.gwt.event.dom.client.FocusHandler;
import com.google.gwt.event.dom.client.HasClickHandlers;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.FocusWidget;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.TextBox;
import com.mikesamuel.dateseries.shared.DateSeries;
import com.mikesamuel.dateseries.shared.DateUtil;
import com.mikesamuel.dateseries.shared.Day;
import com.mikesamuel.dateseries.shared.Inference;
import com.mikesamuel.dateseries.shared.WeekDay;

public final class Picker {

  public static final class Specification {
    /** receives the selected dates when a date is selected or unselected. */
    public Callback<Selection> onSelect;
    /** Notified when the user clicks the cancel button. */
    public Callback<Void> onCancel;
    /** receives the completed rule when the user clicks the confirm button. */
    public Callback<Inference> onConfirm;
    /** a string displayed in the confirm button. */
    public String confirmButtonText = "OK";
    /** abbreviations for weekdays.  Defaults to English defaults. */
    public String[] dayAbbrevs = new String[]
      { "Su", "M", "Tu", "W", "Th", "F", "Sa" };
    /**
     * The day of the week of the left-most
     * (rightmost for RTL languages if implemented)
     * column of the interface.
     */
    public WeekDay wkst;
  }

  public interface Callback<T> {
    void handle(T x);
  }

  public final class Selection {
    public final List<Day> days;
    public final Day changed;
    public final Inference inference;

    Selection(
        Collection<? extends Day> days, Day changed, Inference inference) {
      this.days = new ArrayList<Day>(days);
      this.changed = changed;
      this.inference = inference;
    }
  }

  private final Day initiallyVisibleDate;
  private int visibleYear;
  private int visibleMonth;
  private final Document doc;
  private final LinkedHashSet<Day> datesSelected = new LinkedHashSet<Day>();
  private final WeekDay wkst;
  private final InputElement yearDisplay, monthDisplay;
  private final TableSectionElement dates;
  private final DivElement description;
  private final Callback<Selection> onSelect;
  private final ButtonElement confirmButton;

  /**
   * Builds a widget that allows the user to pick a series of dates, and
   * displays an inferred recurrence rule.
   *
   * @param container the node to which the series date picker is attached.
   * @param visibleDate the date which is initially visible.
   */
  public Picker(Element container, Day visibleDate, Specification cfg) {
    this.doc = container.getOwnerDocument();
    this.wkst = cfg.wkst != null ? cfg.wkst : WeekDay.SU;
    this.onSelect = cfg.onSelect;

    this.initiallyVisibleDate = visibleDate;
    this.visibleYear = visibleDate.year();
    this.visibleMonth = visibleDate.month();

    DivElement node = makeDiv("date-series-picker");
    container.appendChild(node);

    // Create a year display with left and right buttons, and an editable text
    // field.
    DivElement yearNode = makeDiv("year");
    node.appendChild(yearNode);
    yearDisplay = makeTextInput("year-disp");
    makeButton(yearNode, "year-prev", adjustYear(-1, yearDisplay), "\u00ab");
    yearNode.appendChild(yearDisplay);
    makeButton(yearNode, "year-next", adjustYear(1, yearDisplay), "\u00bb");
    TextBox yearDisplayInp = TextBox.wrap(yearDisplay);
    onBlur(yearDisplayInp, adjustYear(0, yearDisplay));
    onFocus(yearDisplayInp, new Callback<Void>() {
      public void handle(Void _) { yearDisplay.select(); }
    });

    // Create a month display with left and right buttons, and an editable text
    // field.
    DivElement monthNode = makeDiv("month");
    node.appendChild(monthNode);
    monthDisplay = makeTextInput("month-disp");
    makeButton(monthNode, "month-prev", adjustMonth(-1, monthDisplay), "\u00ab");
    monthNode.appendChild(monthDisplay);
    makeButton(monthNode, "month-next", adjustMonth(1, monthDisplay), "\u00bb");
    TextBox monthDisplayInp = TextBox.wrap(monthDisplay);
    onBlur(monthDisplayInp, adjustMonth(0, yearDisplay));
    onFocus(monthDisplayInp, new Callback<Void>() {
      public void handle(Void _) { monthDisplay.select(); }
    });

    TableElement calendar = doc.createTableElement();
    node.appendChild(calendar);
    calendar.setClassName("cal");
    TableSectionElement weekDays = doc.createTHeadElement();
    weekDays.setClassName("hdrs");
    calendar.appendChild(weekDays);
    TableRowElement weekDayRow = makeTr("week-days");
    weekDays.appendChild(weekDayRow);
    for (int i = 0; i < 7; ++i) {
      TableCellElement weekDay = doc.createTHElement();
      addText(weekDay, cfg.dayAbbrevs[i]);
      weekDayRow.appendChild(weekDay);
    }
    dates = doc.createTBodyElement();
    dates.setClassName("weeks");
    calendar.appendChild(dates);
    for (int j = 0; j < 6; ++j) {
      TableRowElement weekRow = doc.createTRElement();
      dates.appendChild(weekRow);
      for (int i = 0; i < 7; ++i) {
        TableCellElement day = doc.createTDElement();
        weekRow.appendChild(day);
        DivElement cellDiv = doc.createDivElement();
        day.appendChild(cellDiv);
        onClick(Label.wrap(cellDiv), daySelected(j, i));
        cellDiv.setTabIndex(0);
      }
    }

    DivElement controls = makeDiv("ctrls");
    node.appendChild(controls);
    if (cfg.onConfirm != null) {
      final Callback<Inference> onConfirm = cfg.onConfirm;
      this.confirmButton = makeButton(
          controls,
          "conf",
          new Callback<Void>() {
            public void handle(Void _) {
              Inference inference = new Inference(
                  new ArrayList<Day>(datesSelected), wkst);
              onConfirm.handle(inference);
            }
          },
          cfg.confirmButtonText);
    } else {
      this.confirmButton = null;
    }
    if (cfg.onCancel != null) {
      makeButton(controls, "cancel", cfg.onCancel, "Cancel");
    }

    this.description = makeDiv("desc");
    node.appendChild(description);

    updateDisplay();
  }

  Callback<Void> adjustYear(final int delta, final InputElement yearDisplay) {
    return new Callback<Void>() {
      public void handle(Void _) {
        int newYear;
        if (delta != 0) {
          newYear = visibleYear + delta;
        } else {
          try {
            newYear = Integer.parseInt(yearDisplay.getValue().trim());
          } catch (NumberFormatException ex) {
            newYear = visibleYear;
          }
          if (!(newYear >= 1900)) {
            newYear = visibleYear;
          }
        }
        visibleYear = newYear;
        updateDisplay();
      }
    };
  }

  public void setConfirmButtonEnabled(boolean enabled) {
    this.confirmButton.setDisabled(!enabled);
  }

  /** Sets the set of selected dates and updates the display. */
  public void setSelection(Iterable<? extends Day> newSelection) {
    this.datesSelected.clear();

    boolean currentViewContainsSelectedDay = false;
    if (newSelection != null) {
      for (Day selectedDay : newSelection) {
        if (!currentViewContainsSelectedDay && selectedDay.year() == visibleYear
            && selectedDay.month() == visibleMonth) {
          currentViewContainsSelectedDay = true;
        }
        datesSelected.add(selectedDay);
      }
    }

    // Shift the visible year/month to point to the first date or the initially
    // visible date if the prior view does not contain any newly selected dates.
    if (!currentViewContainsSelectedDay) {
      Day visibleDate = datesSelected.isEmpty()
        ? this.initiallyVisibleDate : datesSelected.iterator().next();
      this.visibleYear = visibleDate.year();
      this.visibleMonth = visibleDate.month();
    }

    this.updateDisplay();
  }

  public WeekDay getWkst() { return wkst; }

  private Callback<Void> adjustMonth(
      final int delta, final InputElement monthDisplay) {
    return new Callback<Void>() {
      public void handle(Void _) {
        int newMonth;
        if (delta != 0) {
          newMonth = visibleMonth + delta;
        } else {
          try {
            newMonth = Integer.parseInt(monthDisplay.getValue().trim());
          } catch (NumberFormatException ex) {
            newMonth = visibleMonth;
          }
          if (newMonth >= 1 && newMonth <= 12) {
            newMonth = newMonth - 1;
          } else {
            newMonth = visibleMonth;
          }
        }
        int newYear = visibleYear;
        if (newMonth < 0) {
          --newYear;
          newMonth += 12;
        } else if (newMonth >= 12) {
          ++newYear;
          newMonth -= 12;
        }
        visibleMonth = newMonth;
        visibleYear = newYear;
        updateDisplay();
      }
    };
  }

  private DivElement makeDiv(String className) {
    DivElement el = doc.createDivElement();
    el.setClassName(className);
    return el;
  }

  private ButtonElement makeButton(
      Element container, String className, Callback<Void> onClick,
      String text) {
    ButtonElement el = doc.createPushButtonElement();
    container.appendChild(el);
    el.setClassName(className);
    addText(el, text);
    onClick(Button.wrap(el), onClick);
    return el;
  }

  private InputElement makeTextInput(String className) {
    InputElement el = doc.createTextInputElement();
    el.setClassName(className);
    return el;
  }

  private TableRowElement makeTr(String className) {
    TableRowElement el = doc.createTRElement();
    el.setClassName(className);
    return el;
  }

  private void addText(Element container, String text) {
    container.appendChild(doc.createTextNode(text));
  }

  private void onClick(HasClickHandlers w, Callback<Void> cb) {
    w.addClickHandler(onClick(cb));
  }

  private ClickHandler onClick(final Callback<Void> cb) {
    return new ClickHandler() {
      public void onClick(ClickEvent e) {
        cb.handle(null);
      }
    };
  }

  private void onFocus(FocusWidget fw, final Callback<Void> cb) {
    fw.addFocusHandler(onFocus(cb));
  }

  private FocusHandler onFocus(final Callback<Void> cb) {
    return new FocusHandler() {
      public void onFocus(FocusEvent e) {
        cb.handle(null);
      }
    };
  }

  private void onBlur(FocusWidget fw, final Callback<Void> cb) {
    fw.addBlurHandler(onBlur(cb));
  }

  private BlurHandler onBlur(final Callback<Void> cb) {
    return new BlurHandler() {
      public void onBlur(BlurEvent e) {
        cb.handle(null);
      }
    };
  }

  // The index of the cell containing the first day of the visible month.
  private int day1Cell() {
    WeekDay dow = new Day(visibleYear, visibleMonth, 1).dayOfWeek();
    return (dow.minus(wkst) + 7) % 7;
  }

  private void updateDisplay() {
    yearDisplay.setValue("" + visibleYear);
    monthDisplay.setValue((visibleMonth < 10 ? "0" : "") + visibleMonth);

    int nDays = DateUtil.daysInMonth(visibleYear, visibleMonth);
    NodeList<Element> cells = dates.getElementsByTagName("td");
    int leastCellInMonth = day1Cell();
    int greatestCellInMonth = leastCellInMonth + nDays - 1;
    for (int i = 0, n = cells.getLength(); i < n; ++i) {
      TableCellElement cell = TableCellElement.as(cells.getItem(i));
      String cellText = "", cellClass = "";
      int cellDay = (i - leastCellInMonth + 1);
      Day cellDate = new Day(visibleYear, visibleMonth, cellDay);
      boolean isSelectedCell = datesSelected.contains(cellDate);
      if (1 <= cellDay && cellDay <= nDays) {
        cellText = "" + cellDay;
        cellClass = isSelectedCell ? "sel in-month" : "in-month";
      } else {
        cellClass = isSelectedCell ? "sel not-in-month" : "not-in-month";
      }
      if (cellDate.equals(initiallyVisibleDate)) {
        cellClass += " anchor";
      }
      Element.as(cell.getFirstChild()).setInnerText(cellText);
      cell.setClassName(cellClass);
    }

    cells.getItem(28).getParentElement().setClassName(
        greatestCellInMonth < 28 ? "not-in-month" : "");
    cells.getItem(35).getParentElement().setClassName(
        greatestCellInMonth < 35 ? "not-in-month" : "");
  }

  Callback<Void> daySelected(int j, int i) {
    final int cellIndex = j * 7 + i;
    return new Callback<Void>() {
      public void handle(Void _) {
        Day d = new Day(visibleYear, visibleMonth, cellIndex - day1Cell() + 1);
        if (!datesSelected.remove(d)) {
          datesSelected.add(d);
        }
        updateDisplay();
        Inference currentRule = new Inference(
            new ArrayList<Day>(datesSelected), wkst);
        description.setInnerText(
            currentRule.confidence >= .75 && currentRule.dates.size() > 1
            ? new DateSeries(currentRule.dates.get(0), currentRule.rule)
              .toString()
            : currentRule.dates.size()
              + " date" + (currentRule.dates.size() != 1 ? "s" : ""));
        if (onSelect != null) {
          onSelect.handle(new Selection(datesSelected, d, currentRule));
        }
      }
    };
  }
}
